Skip to content

chief runs as its own system user, with its own Apple ID (#286) - #288

Merged
CrazyWillBear merged 10 commits into
mainfrom
issue-286
Jul 29, 2026
Merged

chief runs as its own system user, with its own Apple ID (#286)#288
CrazyWillBear merged 10 commits into
mainfrom
issue-286

Conversation

@CrazyWillBear

Copy link
Copy Markdown
Collaborator

Builds PRD #286 end to end. Seven commits, done-check green throughout
(777 passed, ruff clean, mypy clean).

What's here

Commit
79d920e imessage.mode switch — self (default, untouched) / dedicated, turning off all four self-DM compensations
5709768 install/account.py + account_steps.py — the account plan as pure data, every argv pinned byte-for-byte
ba2bb65 install/session.pyfdesetup → auto-login or the documented screen-share reconnect
c701ab6 install/posture.py — the boot check, surfaced by chief status and the web statusbar
06913d8 install/dedicated.py + dedicated_ask.py, chief account, install.sh integration, uninstall account flags
143b2cb dual-store reach — chief's store plus the owner's
feb0b38 8670117 docs, including the migration checklist and its abort path

Worth a close look

The dual-store echo trap. Chief's own reply lands in the owner's store as
an ordinary is_from_me = 0 row from chief's handle — and dedicated mode has
already turned off the BOT_PREFIX that used to catch exactly this. Polling
both stores unfiltered rebuilds the echo loop by another route.
imessage.self_handles drops those rows from that store only. Each store also
carries its own cursor, because rowids are per-store and a shared one swallows
everything below the higher rowid across a restart. Both guards are
mutation-checked — removing either fails its test.

The tree does not move. install.sh passes the actual repo dir as
--tree; /opt/chief stays the documented default. Relocating a live tree is
a migration step run by a human, not something the installer should do
underneath itself while running from inside it. The PRD reads as though the
installer places the tree — flagging the deviation rather than burying it.

Secrets carve-out ordering. chmod -R go-rwx secrets/ runs after the
group sweep. Reversed, the sweep re-opens what it just closed. There is a test
pinning the order for that reason.

Line-cap pressure shaped the diff. commands.py, wiring.py, account.py
and app.py all sit a few lines under the CI-enforced 200, so several commits
had to shed or split to fit: web/status_routes.py, install/account_steps.py,
adapters/imessage_fifo.py and adapters/imessage_cursor.py are all extractions,
not new concepts.

Testing

Per the PRD's decision: the central mechanism (dedicated-mode round trip through
the real poll query against a real SQLite store, proving the reply does not
re-enter) plus every generated installer artifact pinned byte-for-byte. The
dual-store guards, the home-root grant refusal and the failed-step abort are
each mutation-checked.

Not done — the real-hardware gate

Step 7 of the PRD is a human-run checklist, not CI, and it is stated as an
acceptance gate rather than pretended into a suite. The feature is not done
until the mini is migrated and six things pass by hand: web UI reachable,
inbound from the owner's phone to chief's new address, outbound reply, a
self-edit commit under chief's identity, a scheduled task firing, and survival
of one full reboot. docs/OPERATIONS.md has the checklist and the abort path.

Two things will complicate that run: #285 is merged to main but not yet
deployed
to the mini (deploying it trips the boot sweep that disables its
existing unscoped classifier monitors — intended, but read the log and rescope
them), and the mini's iMessage send path is pre-existing broken
(osascript -1700), which will muddy verification of the outbound reply.

Closes #286 once that gate passes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc

CrazyWillBear and others added 9 commits July 28, 2026 18:51
`imessage.mode` picks which Apple ID chief speaks as. `self` (default) is
today's install, untouched. `dedicated` is chief on its own Apple ID in its
own user session, and turns off the four self-DM compensations together:

* the self-chat query scope — by polling with an EMPTY scope, never by
  repointing it at the owner's handle, which is now chief's real conversation
  with them and would poll chief's own replies back as owner input;
* the BOT_PREFIX stamp on replies and its inbound filter;
* the twin-row dedup (a self-DM artefact of one account writing both rows);
* the out-of-band imsg/osascript send guard.

`owner_handles` keeps its meaning in both modes. A typo in `mode` is refused
at boot rather than read as `self`, which would leave the echo machinery on
for a chief that has its own Apple ID.

The central mechanism per the PRD: the adapter in dedicated mode driven
through the real poll query against a real SQLite store, round-tripping an
owner message into a reply that does not re-enter.

Docs updated (CONFIG, LIFECYCLE, config.default.yaml). Prose trimmed in
imessage.py to stay under the 200-line cap; the detail lives in LIFECYCLE.md
and SECURITY.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
…286)

Pure, pinned plan: description + exact argv + whose authority each step
needs. Covers both platforms (sysadminctl/dseditgroup, useradd/groupadd/
usermod/loginctl), the shared-group + setgid tree, the secrets carve-out,
chief's git identity and the owner's safe.directory trust.

The password never enters an argv — pinned commands get printed, logged and
diffed. It rides a separate stdin field instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
fdesetup parse decides the mechanism. Unencrypted Mac → the supported
sysadminctl auto-login path (password via stdin, never argv). Encrypted, or
an unreadable state → screen-sharing enablement plus the documented
reconnect-after-reboot step; unknown takes the encrypted branch because
auto-login on an encrypted disk silently does nothing. Linux needs none.

password_conflict() covers macOS refusing auto-login when the login and
Apple ID passwords match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
…286)

macOS auto-login breaks silently after an OS update, so the state is shown
rather than inferred from chief's silence. read_posture() probes fdesetup,
autoLoginUser and the gui/<uid> domain and names the failure modes; on Linux
it probes nothing.

Surfaced in both required places: `chief status` prints it, and the web
statusbar polls /posture beside /monitors. Both routes moved into
web/status_routes.py — app.py was one line under the cap.

account.py split: the Step primitive and the OS-divergent account/group
commands now live in install/account_steps.py (249 lines → 140 + 123).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
`chief account` (new subcommand) offers the three cases — create / use an
existing account / decline — asks the two file-grant questions (default
none, home root refused), prints the whole plan, and runs it under sudo,
stopping at the first failure. A non-interactive run refuses outright rather
than adding a system user unattended; declining anywhere lands on exactly
today's single-user install.

Taking the account flips imessage.mode to `dedicated` in config.yaml and
writes a key=value report install.sh branches on.

install.sh: --single-user, the account offer after the wizard (so
wizard-written secrets are inside the chown), and in dedicated mode the
service definition is written into chief's home unstarted — launchd cannot
bootstrap into a session that does not exist yet — with the remaining human
steps printed instead of a health wait.

uninstall now asks about the system account, with --remove-account /
--keep-account as the scripted answers. Its home holds chief's message
store, so keeping is the default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
Dedicated mode polls both, so the owner's existing monitors keep working
after chief moves to its own Apple ID. Two config keys: imessage.owner_db_path
and imessage.self_handles.

The trap this closes: chief's reply lands in the OWNER's store as an ordinary
is_from_me = 0 row from chief's handle, and dedicated mode has already turned
off BOT_PREFIX — so polling both stores unfiltered re-creates the echo loop by
another route. Rows from chief's own handles are dropped from the owner's
store, mutation-checked.

Rowids are per-store, so each store carries its own cursor (a shared one
swallows everything below the higher rowid across a restart — also
mutation-checked).

To fit the 200-line cap: the per-thread FIFO machinery moved to
adapters/imessage_fifo.py, and the cursor + per-store position to
adapters/imessage_cursor.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
…#286)

Documentation is part of the change, not a follow-up — every doc describing a
single-user install was stale the moment the account landed.

- OPERATIONS: the account flow (interactive-only, three cases, sudo on the
  terminal), why the install ends without starting the daemon, the boot check,
  the new `chief account` command and the uninstall account flags, and the
  full migration checklist with its explicit abort path.
- SECURITY: a new "account boundary" section — what it buys, and the two things
  it deliberately does not cover (no escalation ever; read access to the
  owner's message store, stated plainly). Three invariants added.
- CONFIG / LIFECYCLE: the dual-store keys and the echo trap they close; the
  poll walk over stores, ThreadFifo and Store module moves.
- README / CLAUDE / ARCHITECTURE: the posture is now the headline of what
  chief runs as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
…ount`

The launcher's dispatch list and USAGE, install.sh's --help range (the header
grew), install.sh's own description, and bootstrap.sh's flag passthrough all
needed to know about the account step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
#288)

The dedicated install path was never executed end-to-end: the e2e workflow
runs --non-interactive, which skips it entirely. Four of these would abort
or silently mis-install on real hardware.

- The account plan chowns the tree to chief while the installer is still
  writing into it, and group membership does not reach an already-running
  process. config.yaml and the report install.sh branches on now land
  before the first permission step; a failed step aborts the installer,
  which never reads either file.
- The launchd/systemd definition is written BY chief, under `sudo -u`:
  chief's home is not owner-writable (macOS ~/Library is 0700) and the
  compensating chown ran after the write it was meant to enable.
- Guard the traversal the tree and launcher need from chief's side — they
  sit under the owner's home, which is 0700 on distros honouring HOME_MODE.
- `grep -qE`: BSD grep does not understand BRE alternation, and a silent
  no-match installed the service into the owner's account instead.
- `chief status` probes chief's account, not whoever typed it — the owner
  is the only one who ever runs it, so it reported their session as chief's.
- Reject `owner_db_path` set without `self_handles`: that handle list is the
  only thing stopping chief from reading its own replies back out of the
  owner's store, with BOT_PREFIX already off.
- An unreadable owner store costs chief that store, not the daemon and the
  web UI with it; the migration checklist now names the two grants it needs.
- An existing account keeps its real home instead of a guessed /Users/<user>.
- read_posture off the event loop: three subprocesses every 10s per tab.
- `problems()` treats unknown encryption as encrypted, like session_plan.
- The mode flip anchors on the `imessage:` block, not the file's first
  `mode:` key; it moved to config/write.py next to the other config writer.
- SECURITY.md invariant 12 says plainly that the boundary is accident-scoped:
  the launcher and git run chief-authored code as the owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
@CrazyWillBear

Copy link
Copy Markdown
Collaborator Author

Review pass (/my-review, opus) done — 3 high, 10 medium, 3 low. Fixes pushed as c79e16c.

Fixed on this branch (11):

# Sev What
1 high The account plan chowned the tree to chief while the installer was still writing into it; group membership does not reach an already-running process, so config.yaml and the report both hit PermissionError and aborted the install with a real account and a half-configured box. Both writes now land before the first permission step.
2 high The service definition was written into chief's home by the owner (macOS ~/Library is 0700); the compensating chown ran after the write it was meant to enable and swallowed its own failure. Now written by chief under sudo -u.
10 med grep -q '^mode=\(create|existing\)$' is GNU BRE — BSD grep does not understand it, and a silent no-match installed the service into the owner's account and started it. Now grep -qE.
11 med The tree and launcher sit under the owner's home, which is 0700 on distros honouring HOME_MODE. Traversal is now checked from chief's side and fails with a message rather than shipping a unit that cannot start.
5 med chief status probed the invoking user — the owner is the only one who ever types it, so it reported their session as chief's and printed the auto-login warning backwards. New posture.chief_account() resolves chief from the install report.
7 med owner_db_path without self_handles is now a ConfigError. That handle list is the only thing stopping chief reading its own replies back out of the owner's store, with BOT_PREFIX already off.
9 med An unreadable owner store took Daemon.start down, and the web UI with it — the other half of the boot check. It now costs chief that store only (its own still fails loudly). The migration checklist names the two grants it needs: ~/Library/Messages is drwx------, and macOS TCC is per-user so chief needs its own Full Disk Access.
6 med read_posture ran three blocking subprocesses on the event loop, every 10s per open tab. Now asyncio.to_thread.
12 med An existing account keeps its real home (pwd.getpwnam) instead of a guessed /Users/<user>, which is where the plist would otherwise land.
14 low The imessage.mode flip anchors on the imessage: block, not the file's first mode: key. Moved to config/write.py, next to the other config writer.
15 low Posture.problems() treats unknown encryption as encrypted, matching session_plan.

Finding 3 (high) — decided, not deferred. The launcher runs chief-authored code from a chief-writable tree as the owner, which the previous invariant-12 wording did not admit. docs/SECURITY.md now says plainly that the boundary is accident-scoped, not adversary-proof, with a new "What the boundary does not cover" section. Closing it properly means re-execing the launcher under sudo -u chief; that is a behaviour change with a sudo prompt on every lifecycle command and is not in scope here.

Filed, not fixed: #289 (grant_reason bypassed by ~/..), #290 (dual-store double-delivers group chats), #291 (uninstall account guard), #292 (verify sysadminctl stdin on real macOS — hardware-gated).

Mock-drift audit: clean, no mock-debt filed. The declared central mechanism runs the real adapter and the real POLL_QUERY against a real SQLite store.

Done-check green: 785 passed, ruff clean, mypy clean, shellcheck clean. New regression tests pin the write-ordering (asserts config.yaml and the report exist at the moment the chown step runs), the block-anchored mode flip, the real-home read, chief_account, unknown-encryption posture, the config validation, and both halves of the store-degrade behaviour.

start() leaves the poll loop running, so the explicit poll_once() that
follows raced its first tick — both fetched the same row before either
advanced the cursor, and the message was delivered twice. Green locally,
red on CI. Stop the adapter before polling by hand, which is what the
other tests in this file already do.

Test-only: nothing calls poll_once() concurrently in production, where
_poll_loop is the sole caller and runs serially.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9h4tbLEueBBGBNG3Bc7sc
@CrazyWillBear
CrazyWillBear merged commit 77338af into main Jul 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chief runs as its own system user, with its own Apple ID

1 participant